忍者讀書會-以原型來實現物件導向

2022-06-12 Sun

本篇參與忍者:JavaScript 開發技巧探秘 第二版讀書會的導讀內容加上自己所蒐尋的資料後所構成的文章。

瞭解原型

繼承

  • 一種可以重用程式碼,組織整個程式架構的一種方式。
  • 把存在某物件的功能,延伸到其他物件上。

in 運算子

如果某個屬性在其物件或物件的原型上的話,則 in 運算子會回傳 true。

setPrototypeOf

Object.setPrototypeOf(Object1,Object2)

使用方法是帶入兩個物件到引數當中,把第二個引數設為第一個引數的原型。

這時候我們稱 Object2 是 Obeject1 物件的原型。

const yoshi = { skulk: true };
const hatori = { sneak: true };
const kuma = { creep: true };
//透過 in 運算子檢查某屬性是否有在該物件中
console.log("skulk" in yoshi); //true
console.log("sneak" in yoshi); //false
//設定hatori是yoshi的原型
Object.setPrototypeOf(yoshi, hatori);
//由於設定原型,本身yoshi沒有該屬性的時候就會順著原型往上查找,因此印出true
console.log("sneak" in yoshi);
Object.setPrototypeOf(hatori, kuma);
//同理,由於yoshi沒有creep屬性,但是順著hatori查找,並且順著原型練查找kuma的話就能找到creep屬性
console.log("creep" in yoshi);

可以使用console.dir(yoshi)來查看物件原型之間的關係

原型鍊-prototype chain

每個物件都可以擁有原型,而這個物件也可以擁有自己的原型,所以就可以串聯起來,稱為原型鍊。

物件建構與原型

在 javascript 當中,函式這個物件比較特別的地方是內建一個prototype這個物件。

function f() {}
console.dir(f); //可以看到擁有prototype這個屬性

var object = {};
console.dir(object);

console.dir(f)後將會印出如下圖可以發現具有 prototype 這個特殊的物件

打開其屬性如下圖,可以發現 prototype 擁有 constructor 這個屬性,其對應到的就是 f 函式本身。

倘若印出console.dir(object);的時候並沒有 prototype 這個物件如下圖

由於函式擁有 prototype 這個特殊的物件,因此當我們在撰寫建構器函式的時候可以對這個特殊的物件添加屬性。

//這裡將method儲存在原型中。
function Ninja() {}
Ninja.prototype.swingSword = function () { //添加一個方法到Ninja的prototype物件
  return true;
};
const ninja1 = Ninja();
//由於Ninja沒有回傳值,因此一般的函式呼叫是undefined
console.log(ninja1 === undefined);
//但透過new關鍵字來使用Ninja建構函式的時候,將會初始化一個物件。
const ninja2 = new Ninja();
console.log(ninja2);
console.log(ninja2.swingSword);
//ninja2擁有方法,但不是來自於ninja2,而是來自於原型,因此稱為原型方法。
console.log(ninja2.swingSword());

需要注意的是不要寫Ninja.prototype= function (){},我們是其 prototype 這個物件添加屬性,其值是一個方法。 如果直接對其修改的話,將會破壞 prototype 這個物件。

參考資料

MDN-使用原型鍊繼承

如下圖 ninja2 透過[[prototype]]找到他的原型,而 Ninja 這個建構器函式透過prototype這個物件作為之後透過建構器函式所建立的物件的原型。

觀念重點[[prototype]]不等於 prototype

原型屬性 vs 實例屬性

以下的範例當中 Ninja.prototype.swingSword 這裡我們稱之為原型屬性

this.swingSword 這一行所建立的屬性我們稱為實例屬性

function Ninja() {
  //透過  this.xxx所建立的的屬性稱為實例屬性
  this.swung = false;
  this.swingSword = function () {
    return !this.swung; /
  };
}
//這裡所建立的是原型屬性
Ninja.prototype.swingSword = function () {
  return this.swung;
};
//   以下透過new建立出ninja這個物件
const ninja = new Ninja();

這時候我們呼叫 swingSword 這個方法會顯示 true 由於透過建構器函式所建立出來的物件會先查找是否自己擁有這個方法(查找實例屬性),如果沒有才會順著原型查找(查找原型屬性)

實務上不要寫出這種令人混淆的程式碼,這邊只是為了展示會先查找實例屬性的方法而已。

換句話說

如果實例屬性可以被找到其方法,原型屬性的方法就會被跳過。

為什麼使用原型屬性

swingSword 方法的邏輯是一樣,卻建立出三個版本,顯然浪費記憶體空間。 以下透過console.log(ninja1.swingSword ===ninja2.swingSword) 印出會回傳 false 由於他們三個物件實例將會建立出三個 function

function Ninja() {
  this.swung = false;
  this.swingSword = function () {
    return !this.swung;
  };
}
const ninja1 = new Ninja();
const ninja2 = new Ninja();
const ninja3 = new Ninja();
console.log(ninja1.swingSword ===ninja2.swingSword);//false

倘若如果我們將其方法設定再原型屬性上的時候,他們所指向的函式將會是同一個,換句話說共用邏輯可以抽離出來指向同一個函式,這樣的作法比較符合程式設計(不要重複造輪子) 參見以下程式碼

function Ninja() {
  this.swung = false;
  Ninja.prototype.swingSword = function () {
    return !this.swung;
  };
}
const ninja1 = new Ninja();
const ninja2 = new Ninja();
const ninja3 = new Ninja();
console.log(ninja1.swingSword ===ninja2.swingSword);//true

動態改變物件原型或函式原型產生的副作用

由於我們可以動態的添加屬性到 prototype 這個物件,下面的範例當中,ninja1 在被建立前擁有 swingSword 之後將 Ninja 的 prototype 覆蓋掉後,雖然 ninja1 仍然可以使用 swingSword,但是 ninja2 卻不能 swingSword

function Ninja() {
  this.swung = true;
}
const ninja1 = new Ninja();
Ninja.prototype.swingSword = function () {
  return this.swung;
};

//swingSword方法透過原型存在於ninja1
console.log(ninja1.swingSword());
//透過覆蓋Ninja的prototype
Ninja.prototype = {
  pierce: function () {
    return true;
  },
};
//ninja1仍然可以swing
console.log(ninja1.swingSword())

const ninja2 = new Ninja();
//ninja2可以pierce
console.log(ninja2.pierce());
//ninja2不能swingSword
console.log(ninja2.swingSword());

instanceof

透過 ninja 可以檢測 Ninja 是否為他的建構函式 (在傳統 OOP 當中稱之為實體)

function Ninja(){}
const ninja = new Ninja();
console.log(typeof ninja === "object");
console.log(ninja instanceof Ninja);
console.log(ninja instanceof Object );//印出true,因為任何物件都是Object的建構函式
console.log(ninja.constructor === Ninja);//可以透過constructor得ninja是藉由哪個建構器函式所建立出來的

透過 constructor 屬性 初始化一個物件

由於 constructor 屬性可以指到建構器函式,因此我們也可以建立出物件實體後透過 constructor 初始化一個物件 參見以下的範例

function Ninja(){}//建構器函式
const ninja = new Ninja();//建立ninja物件實體
const ninja2 = new ninja.constructor();//透過ninja物件實體的constructor建立出ninja2

修改掉物件的原型

以下的範例我們發現,如果透過 Ninja 所建立出來的實體後,再將建構器函式修改掉原型,最後使用使用檢查 ninja instanceof Ninja 後,由於原型被重新指向了,因此當然與先前透過原建構器建立的實體是不同的。

對於 instance of 更正確的說法是檢查右側函式的原型是不是在左側物件的原型鏈上

Javascript 可以在任何時候修改函式的原型

function Ninja(){}
const ninja = new Ninja();
Ninja.prototype ={};
ninja instanceof Ninja

嘗試用物件原型實現繼承

下列的程式碼僅複製 Person 的 dance 方法,但是 ninja 並非是一個人即便他會跳舞,這個並非繼承的範例。

function Person() {}
  Person.prototype.dance = function () {
    console.log("I m dancing");
  };
function Ninja() {}
Ninja.prototype = { dance: Person.prototype.dance };
const ninja = new Ninja();
console.log(ninja.dance());
console.log(ninja instanceof Ninja);
console.log(ninja instanceof Person);

使用原型實現繼承

當我們使用 new 關鍵字將 new 出來的實體加在 Ninja 的 prototype 上時,這時候檢查 ninja instanceof Person 就可以發現他是一個人,換句話說,這樣的方式就有將 ninja、Ninja 和 Person 串起整個原型鏈了。

 function Person() {}
Person.prototype.dance = function () {
  console.log("I m dancing");
};
function Ninja() {}
Ninja.prototype = new Person();
const ninja = new Ninja();
console.log(ninja.dance());
console.log(ninja instanceof Ninja);
console.log(ninja instanceof Person);

但上述的例子會出現一個問題是如果我們嘗試著印出 ninja 的 constructor 的話,會出現 Person。

console.log(ninja.constructor);

但 constructor 的用途是得到物件是藉由哪個建構器函式所建立的,由於先前將 new 出來的 Person 實體載入在 Ninja 的 prototype 上面,因此當我們再次透過 Ninja 建構器函式建構出 ninja 的時候會造成 constructor 指向錯誤的建構器函式。

因此當我們使用 prtotype 來實現繼承的時候也會一併修改 constructor 屬性來修正錯誤的指向。

要修改 constructor 屬性的話,我們可以透過屬性描述子(property descriptor)

這邊先簡單介紹一下屬性描述子

參見以下範例從新定義了 Ninja.prototype 的 constructor 屬性。 如此一來就可以讓建立出來的 ninja 實體的 constructor 正確指向 Ninja

function Person() {}
Person.prototype.dance = function () {};

function Ninja() {}
Ninja.prototype = new Person();
Object.defineProperty(Ninja.prototype, "constructor", {
  enumerable: false,
  value: Ninja,
  writable: true,
});
const ninja = new Ninja();
console.log(ninja.constructor);
console.log(ninja instanceof Person);

class 關鍵字

class 可以讓我們方便定義一個建構器函式。 類別的寫法是一種語法糖。

class Ninja{
  constructor(name){
    this.name = name;//當我們new出一個實體的時候這一段程式碼會被執行
  }

  swingSword(){
    return true;
  }
}
var ninja = new Ninja("Yoshi");
console.log(ninja instanceof Ninja);
console.log(ninja.name === "Yoshi");
console.log(ninja.swingSword());
//這邊出現的結果會與先前使用建構器函式建構實體的的方式雷同

class 的靜態方法

靜態方法是類別層級,而非實體層級的,換句話說,只能給類別使用,透過類別呼叫方法來使用。

 class Ninja {
  constructor(name, level) {
    this.name = name;
    this.level = level;
  }

  swingSword() {
    //建立的實體才能被使用
    return true;
  }
  static compare(ninja1, ninja2) {
    //只能透過類別作使用
    return ninja1.level - ninja2.level;
  }
}
//靜態方法只能給類別使用,不能被建立出來的實體使用。
const ninja1 = new Ninja("Yoshi", 4);
const ninja2 = new Ninja("Hatori", 3);
console.log(
  ninja1.compare === "undefined" && ninja2.compare === "undefined"
);
console.log(Ninja.compare(ninja1, ninja2) > 0);
console.log(Ninja.swingSword);

如果是在 ES5 之前的話就是直接在建構器函式上面加入方法實現靜態方法 程式碼如下

function Ninja(){
}
Ninja.compare = function (ninja1,ninja2){}

使用 class 實現繼承

使用 class 實現繼承方式如下

class Person {
  constructor(name){
    this.name = name;
  }

  dance(){
    return true;
  }
}

class Ninja extends Person {
  constructor(name, weapon){
    // super(name);
    this.weapon = weapon;
  }
  wieldWeapon(){
    return true;
  }
}
const person = new Person("Bob");

另外如果你去console.log(person.constructor)的話會印出 class 版本的樣貌